You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

285 lines
8.1 KiB

<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
import { normalizePostSlugCandidate } from '../../../utils/post-slug'
const route = useRoute()
const id = computed(() => route.params.id as string)
const { user } = useAuthSession()
const { fetchData } = useClientApi()
const toast = useToast()
const state = reactive({
title: '',
slug: '',
excerpt: '',
bodyMarkdown: '',
visibility: 'private',
shareToken: '' as string | null,
})
const loading = ref(true)
const saving = ref(false)
const visibilityItems = [
{ label: '私密', value: 'private' },
{ label: '公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
]
const bodyLength = computed(() => state.bodyMarkdown.trim().length)
const publicPostHref = computed(() => {
const ps = user.value?.publicSlug
if (!ps || !state.slug) {
return ''
}
return `/@${ps}/posts/${encodeURIComponent(state.slug)}`
})
async function load(options?: { silent?: boolean }) {
const silent = options?.silent === true
if (!silent) {
loading.value = true
}
try {
const { post: p } = await fetchData<{ post: typeof state }>(`/api/me/posts/${id.value}`)
Object.assign(state, {
title: p.title,
slug: p.slug,
excerpt: p.excerpt,
bodyMarkdown: p.bodyMarkdown,
visibility: p.visibility,
shareToken: p.shareToken ?? null,
})
} finally {
if (!silent) {
loading.value = false
}
}
}
onMounted(() => {
void load()
})
watch(id, () => {
void load()
})
usePageTitle(() => {
const t = state.title.trim()
return t ? [t, '编辑'] : ['编辑文章']
})
function generateSlugFromTitle() {
if (!state.title.trim()) {
toast.add({ title: '请先填写标题', color: 'warning' })
return
}
const previous = state.slug
const normalized = normalizePostSlugCandidate(state.title)
state.slug = normalized
if (!state.slug) {
toast.add({ title: '未生成 slug,请检查标题内容', color: 'warning' })
return
}
if (state.slug === previous) {
toast.add({ title: 'slug 未变化', color: 'neutral' })
return
}
toast.add({ title: '已生成 slug', color: 'success' })
}
async function save() {
saving.value = true
try {
await fetchData(`/api/me/posts/${id.value}`, {
method: 'PUT',
body: {
title: state.title,
slug: state.slug,
excerpt: state.excerpt,
bodyMarkdown: state.bodyMarkdown,
visibility: state.visibility,
},
})
await load({ silent: true })
toast.add({ title: '文章已保存', color: 'success' })
} finally {
saving.value = false
}
}
async function remove() {
await fetchData(`/api/me/posts/${id.value}`, { method: 'DELETE' })
toast.add({ title: '文章已删除', color: 'success' })
await navigateTo('/me/posts')
}
const shareUrl = computed(() => {
const slug = user.value?.publicSlug?.trim()
if (state.visibility !== 'unlisted' || !state.shareToken || !slug) {
return ''
}
if (import.meta.client) {
return `${window.location.origin}/p/${encodeURIComponent(slug)}/t/${encodeURIComponent(state.shareToken)}`
}
return ''
})
async function copyShareUrl() {
if (!shareUrl.value || !import.meta.client) {
return
}
try {
await navigator.clipboard.writeText(shareUrl.value)
toast.add({ title: '分享链接已复制', color: 'success' })
} catch {
toast.add({ title: '复制失败,请手动复制', color: 'warning' })
}
}
</script>
<template>
<UContainer class="py-8 max-w-[1600px] space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-semibold tracking-tight">
编辑文章
</h1>
<div class="flex flex-wrap items-center gap-2">
<UButton
v-if="publicPostHref"
:to="publicPostHref"
variant="soft"
color="neutral"
size="sm"
>
详情
</UButton>
<UButton to="/me/posts" variant="ghost" color="neutral" size="sm">
返回列表
</UButton>
<!-- <UButton type="submit" form="edit-post-form" :loading="saving" size="sm">
保存文章
</UButton> -->
</div>
</div>
<div v-if="loading" class="text-muted">
加载中…
</div>
<template v-else>
<UForm
id="edit-post-form"
:state="state"
class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]"
@submit.prevent="save"
>
<div class="space-y-6">
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
<UFormField label="标题" name="title" required class="w-full">
<UInput
v-model="state.title"
class="w-full"
placeholder="例如:我的 2026 开发工作流复盘"
/>
</UFormField>
<UFormField label="摘要" name="excerpt" required class="w-full">
<UTextarea
v-model="state.excerpt"
class="w-full"
:rows="3"
autoresize
placeholder="一句话概括文章核心内容,便于列表页快速浏览。"
/>
</UFormField>
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-3' }">
<div class="flex items-center justify-between gap-3">
<h2 class="text-base font-medium">
正文内容
</h2>
<span class="text-xs text-muted">字数 {{ bodyLength }}</span>
</div>
<PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
<UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
</UCard>
</div>
<div class="space-y-6 xl:sticky xl:top-20 xl:self-start">
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-4' }">
<h2 class="text-base font-medium">
发布设置
</h2>
<UFormField label="slug" name="slug" required>
<div class="flex items-center gap-2">
<UInput v-model="state.slug" placeholder="my-post-slug" class="flex-1" />
<UButton
type="button"
variant="soft"
color="neutral"
icon="i-lucide-wand-sparkles"
label="生成"
@click="generateSlugFromTitle"
/>
</div>
</UFormField>
<UFormField label="可见性" name="visibility">
<USelect v-model="state.visibility" :items="visibilityItems" />
</UFormField>
<div
v-if="shareUrl"
class="rounded-md border border-default bg-elevated/40 p-3 space-y-2"
>
<p class="text-sm font-medium">
仅链接分享
</p>
<div class="flex flex-col gap-2">
<UInput
readonly
size="sm"
class="font-mono text-xs w-full min-w-0"
:model-value="shareUrl"
/>
<div class="flex flex-wrap gap-2">
<UButton size="sm" @click="copyShareUrl">
复制链接
</UButton>
<UButton
size="sm"
color="neutral"
variant="outline"
:to="shareUrl"
target="_blank"
>
打开
</UButton>
</div>
</div>
</div>
</UCard>
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-3' }">
<h2 class="text-base font-medium">
操作
</h2>
<UButton type="submit" :loading="saving" block>
保存文章
</UButton>
<UButton to="/me/posts" variant="ghost" color="neutral" block>
取消并返回
</UButton>
<UButton color="error" variant="soft" type="button" block @click="remove">
删除文章
</UButton>
</UCard>
</div>
</UForm>
</template>
</UContainer>
</template>